| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204 |
- import fs from "node:fs";
- import fsp from "node:fs/promises";
- import path from "node:path";
- import { Readable } from "node:stream";
- import { getSession } from "@/lib/auth/session";
- import { canAccessBranch } from "@/lib/auth/permissions";
- import {
- withErrorHandling,
- badRequest,
- unauthorized,
- forbidden,
- notFound,
- ApiError,
- } from "@/lib/api/errors";
- import { mapStorageReadError } from "@/lib/api/storageErrors";
- export const dynamic = "force-dynamic";
- export const runtime = "nodejs";
- const BRANCH_RE = /^NL\d+$/;
- const YEAR_RE = /^\d{4}$/;
- const MONTH_RE = /^(0[1-9]|1[0-2])$/;
- const DAY_RE = /^(0[1-9]|[12]\d|3[01])$/;
- function getNasRootOrThrow() {
- const root = process.env.NAS_ROOT_PATH;
- if (!root) {
- throw new ApiError({
- status: 500,
- code: "FS_STORAGE_ERROR",
- message: "Internal server error",
- });
- }
- return root;
- }
- function isSafeFilename(name) {
- if (typeof name !== "string") return false;
- const trimmed = name.trim();
- if (!trimmed) return false;
- // Reject special path segments
- if (trimmed === "." || trimmed === "..") return false;
- // Reject any path separators (defense-in-depth)
- if (trimmed.includes("/") || trimmed.includes("\\")) return false;
- // Reject control chars (header injection)
- if (/[\r\n\t]/.test(trimmed)) return false;
- // Reject quotes to keep Content-Disposition predictable/safe
- if (trimmed.includes('"')) return false;
- // Ensure it's a basename (no sneaky segments)
- if (path.basename(trimmed) !== trimmed) return false;
- return true;
- }
- function isPdfFilename(name) {
- return typeof name === "string" && name.toLowerCase().endsWith(".pdf");
- }
- function validateParamsOrThrow({ branch, year, month, day, filename }) {
- if (!BRANCH_RE.test(branch)) {
- throw badRequest("VALIDATION_BRANCH", "Invalid branch parameter", {
- branch,
- });
- }
- if (!YEAR_RE.test(year)) {
- throw badRequest("VALIDATION_YEAR", "Invalid year parameter", { year });
- }
- if (!MONTH_RE.test(month)) {
- throw badRequest("VALIDATION_MONTH", "Invalid month parameter", { month });
- }
- if (!DAY_RE.test(day)) {
- throw badRequest("VALIDATION_DAY", "Invalid day parameter", { day });
- }
- if (!isSafeFilename(filename)) {
- throw badRequest("VALIDATION_FILENAME", "Invalid filename parameter", {
- filename,
- });
- }
- if (!isPdfFilename(filename)) {
- throw badRequest(
- "VALIDATION_FILE_EXTENSION",
- "Only PDF files are allowed",
- { filename }
- );
- }
- }
- function resolvePdfPathOrThrow({ root, branch, year, month, day, filename }) {
- const rootAbs = path.resolve(root);
- const absPath = path.resolve(rootAbs, branch, year, month, day, filename);
- // Ensure the resolved path stays within NAS_ROOT_PATH
- const rel = path.relative(rootAbs, absPath);
- if (rel.startsWith("..") || path.isAbsolute(rel)) {
- throw badRequest("VALIDATION_PATH_TRAVERSAL", "Invalid file path", {
- branch,
- year,
- month,
- day,
- filename,
- });
- }
- return absPath;
- }
- /**
- * GET /api/files/:branch/:year/:month/:day/:filename
- *
- * Query (optional):
- * - download=1 | download=true => Content-Disposition: attachment
- * - default => inline
- */
- export const GET = withErrorHandling(
- async function GET(request, ctx) {
- const session = await getSession();
- if (!session) {
- throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
- }
- const { branch, year, month, day, filename } = await ctx.params;
- const missing = [];
- if (!branch) missing.push("branch");
- if (!year) missing.push("year");
- if (!month) missing.push("month");
- if (!day) missing.push("day");
- if (!filename) missing.push("filename");
- if (missing.length > 0) {
- throw badRequest(
- "VALIDATION_MISSING_PARAM",
- "Missing required route parameter(s)",
- { params: missing }
- );
- }
- if (!canAccessBranch(session, branch)) {
- throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden");
- }
- validateParamsOrThrow({ branch, year, month, day, filename });
- const root = getNasRootOrThrow();
- const absPath = resolvePdfPathOrThrow({
- root,
- branch,
- year,
- month,
- day,
- filename,
- });
- const details = { branch, year, month, day, filename };
- let stat;
- try {
- stat = await fsp.stat(absPath);
- } catch (err) {
- throw await mapStorageReadError(err, { details });
- }
- if (!stat.isFile()) {
- throw notFound("FS_NOT_FOUND", "Not found", details);
- }
- const { searchParams } = new URL(request.url);
- const download = (searchParams.get("download") || "").toLowerCase();
- const asAttachment = download === "1" || download === "true";
- const dispositionType = asAttachment ? "attachment" : "inline";
- const contentDisposition = `${dispositionType}; filename="${filename}"`;
- const nodeStream = fs.createReadStream(absPath);
- const webStream = Readable.toWeb(nodeStream);
- return new Response(webStream, {
- status: 200,
- headers: {
- "Content-Type": "application/pdf",
- "Content-Disposition": contentDisposition,
- "Content-Length": String(stat.size),
- "Cache-Control": "no-store",
- "X-Content-Type-Options": "nosniff",
- },
- });
- },
- { logPrefix: "[api/files/[branch]/[year]/[month]/[day]/[filename]]" }
- );
|